feat: BLE-over-WebSocket proxy — @matter/ws-ble package + generic ws-proxy framing in @matter/general - #4227
feat: BLE-over-WebSocket proxy — @matter/ws-ble package + generic ws-proxy framing in @matter/general#4227Apollon77 wants to merge 27 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generic WebSocket-proxy connection over an HttpEndpoint.WsConnection stream pair: hello handshake in either role, correlated JSON commands, events, binary frames and an inbound command handler. Generalizes the responder-only, ws-socket-based BLE proxy connection from matterjs-server, keeping the wire format compatible. Notable differences from that implementation: a command that times out now rejects instead of leaving its promise permanently pending, command IDs skip IDs still in flight so a 16-bit wrap cannot orphan a pending command, and an inbound command with no handler installed is answered rather than dropped. ProxyCommandError gains `detail`, the wire message without the code prefix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ned() helper A close that lands while a handshake response is in flight could be overwritten: the handshake state was committed after an awaited write, so the connection reported itself open after `closed` had already been emitted. Commit the handshake through a guard that refuses once the terminal state is reached, and ignore inbound traffic that arrives after it. Add `opened()`, which resolves on handshake completion and rejects if the connection closes first, so consumers no longer race `handshakeCompleted` against `closed`. Observers are removed once it settles and no promise is retained, so an uncalled `opened()` cannot leave a rejection unobserved. `sendFrame` now reports a closed connection before validating opcode and handle range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eference Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aths Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lient Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…adapter readiness Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ty notes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-proxy exports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces generic WebSocket proxy framing and a new BLE-over-WebSocket implementation for remote Matter commissioning.
Changes:
- Adds reusable WebSocket handshake, command, event, and binary-frame infrastructure.
- Adds
@matter/ws-ble, including hub, BLE transport, Noble client, and CLI. - Adds integration tests, documentation, workspace configuration, and changelog entries.
Reviewed changes
Copilot reviewed 33 out of 35 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
tsconfig.json |
References the new package. |
packages/ws-ble/tsconfig.json |
Configures package projects. |
packages/ws-ble/test/WsSmokeTest.ts |
Tests a real WebSocket round trip. |
packages/ws-ble/test/tsconfig.json |
Configures package tests. |
packages/ws-ble/test/support/MockBleDevice.ts |
Provides a mock Matter BLE peripheral. |
packages/ws-ble/test/support/BleProxyTestClient.ts |
Implements the proxy test client. |
packages/ws-ble/test/MultiClientBleProxyTest.ts |
Tests multi-client ownership and scanning. |
packages/ws-ble/test/BleProxyProtocolTest.ts |
Tests protocol framing vectors. |
packages/ws-ble/test/BleProxyIntegrationTest.ts |
Tests the complete proxy BLE pipeline. |
packages/ws-ble/test/BleProxyConnectionTest.ts |
Tests BLE proxy connection behavior. |
packages/ws-ble/src/tsconfig.json |
Configures source compilation. |
packages/ws-ble/src/ProxyBleScanner.ts |
Adapts the standard BLE scanner. |
packages/ws-ble/src/ProxyBleClient.ts |
Handles proxy scanning and discoveries. |
packages/ws-ble/src/ProxyBleChannel.ts |
Implements proxy BTP channels and transport. |
packages/ws-ble/src/ProxyBle.ts |
Exposes the proxy Ble implementation. |
packages/ws-ble/src/noble-client/NobleBleProxyClient.ts |
Implements the Noble hardware client. |
packages/ws-ble/src/noble-client/index.ts |
Exports the Noble client. |
packages/ws-ble/src/noble-client/cli.ts |
Adds the proxy CLI. |
packages/ws-ble/src/index.ts |
Exports package APIs. |
packages/ws-ble/src/BleProxyProtocol.ts |
Defines BLE proxy protocol types. |
packages/ws-ble/src/BleProxyHandler.ts |
Implements the multi-client hub. |
packages/ws-ble/src/BleProxyConnection.ts |
Wraps generic proxy connections. |
packages/ws-ble/README.md |
Documents architecture, security, and usage. |
packages/ws-ble/package.json |
Defines package exports and dependencies. |
packages/ws-ble/LICENSE |
Adds the package license. |
packages/general/test/net/ws-proxy/WsProxyFrameTest.ts |
Tests generic binary framing. |
packages/general/test/net/ws-proxy/WsProxyConnectionTest.ts |
Tests generic connection lifecycle and messaging. |
packages/general/src/net/ws-proxy/WsProxyMessage.ts |
Defines shared message envelopes and errors. |
packages/general/src/net/ws-proxy/WsProxyFrame.ts |
Implements shared binary framing. |
packages/general/src/net/ws-proxy/WsProxyConnection.ts |
Implements shared WebSocket proxy connections. |
packages/general/src/net/ws-proxy/index.ts |
Exports proxy infrastructure. |
packages/general/src/net/index.ts |
Re-exports proxy APIs. |
package.json |
Registers the workspace package. |
package-lock.json |
Locks the new workspace and dependencies. |
CHANGELOG.md |
Records the new framing and BLE package. |
Suppressed comments (2)
packages/ws-ble/src/ProxyBleChannel.ts:417
- A disconnected channel currently reports a successful
send()while dropping the Matter message. Callers then wait for protocol timeouts instead of learning that the transport is gone; the native BLE channel throws on this path. Throw a typed BLE error here.
async send(data: Bytes) {
if (!this.#connected) {
logger.debug(`Cannot send data - not connected to ${this.#peripheralAddress}`);
return;
}
packages/ws-ble/src/ProxyBleChannel.ts:432
- This teardown is not idempotent. Overlapping explicit closes—or the
disconnectedevent triggered by the courtesy Disconnect while the first close is still awaiting its response—run observer cleanup and everyonCloselistener multiple times. Cache and return one close promise (asBleProxyHandlerdoes) so all callers join a single teardown.
async close() {
this.#cleanupObservers();
this.#terminateIterator();
for (const listener of this.#closeListeners) {
listener();
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…validation Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (5)
packages/ws-ble/src/ProxyBleScanner.ts:36
- Closing the scanner only detaches observers; it never calls
stopScanning(). IfProxyBle.close()runs during an active discovery (including runtime shutdown), the handler retains scan intent and connected hardware clients continue scanning. Preserve the base scanner's resource-release behavior by stopping first and detaching infinally.
protected override closeClient(): void {
this.#proxyClient.close();
}
packages/ws-ble/src/BleProxyHandler.ts:164
- A
connectable: falsereport can become the permanent owner even thoughProxyBleClientrejects that same report. If another client later reports the address as connectable, ownership remains with the first connected client andopenChannel()routesconnectto a client that cannot connect. Do not add non-connectable sightings to the ownership pool.
let entry = this.#owners.get(data.address);
packages/ws-ble/src/noble-client/NobleBleProxyClient.ts:435
- The disconnect path drops the connection state without removing the
datalisteners stored insubscriptions. Noble reuses peripheral/characteristic objects, so reconnecting and subscribing again can leave the old listener forwarding duplicate notifications under a stale handle. Remove all subscription listeners before discarding the state, asNobleBleChanneldoes on disconnect.
const disconnectListener = () => {
disconnectedReason = `peripheral disconnected (state=${peripheral.state})`;
logger.info(`[CONN] Peripheral handle=${handle} disconnected (state=${peripheral.state})`);
this.#connections.delete(handle);
this.#sendEvent(BleProxyEvent.Disconnected, { connection_handle: handle });
};
packages/ws-ble/package.json:79
- The package publishes
@matter/ws-ble/noble-client, buttypesVersionsonly maps the root entry. TypeScript consumers using classic/legacy module resolution therefore cannot resolve declarations for the documented subpath. Add a mapping for the exported subpath, consistent with other multi-entry packages such aspackages/react-native/package.json.
"typesVersions": {
"*": {
".": [
"/dist/cjs/index.d.ts"
]
}
packages/ws-ble/src/ProxyBleChannel.ts:440
- Every repeated or overlapping
close()call invokes allonCloselisteners again, even though the inheritedclosedobservable is latched to emit once. This can run consumer teardown multiple times. Clear the listener set after the first notification (or cache one close promise) so close remains idempotent.
for (const listener of this.#closeListeners) {
listener();
}
…endency; order changelog entry Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Adds BLE-over-WebSocket proxy support to matter.js, extracted from matterjs-server's
ble-proxypackage:@matter/generalnet/ws-proxy/— generic, protocol-agnostic WebSocket proxy framing:hello/hello_responseversion handshake, correlated JSON command/response/event envelopes on text frames, binary frames[1B opcode][2B handle BE][payload], andWsProxyConnectionoperating onHttpEndpoint.WsConnectionstream pairs (dial-direction-agnostic: works for server-accepted upgrades andWebSocketClient-dialed connections, supports command-initiator and command-handler roles). This layer is shared groundwork for the planned WS network tunnel (browser support).@matter/ws-ble(new package) — the BLE proxy protocol v1 on top of that framing:BleProxyHandlerhub: mountable viaHttpEndpoint.wsupgrades (accept(wsConnection)), multi-client with per-peripheral ownership trackingProxyBle/ProxyBleScanner/ProxyBleClient/ProxyBleChannel: the matter.jsBleimplementation that runs commissioning over the proxy (BTP viaBtpSessionHandler.createAsCentral)@matter/ws-ble/noble-clientsubpath: hardware-side reference client on@stoprocent/noble+matter-ble-proxyCLI binThe wire protocol is byte-for-byte compatible with deployed matterjs-server BLE proxy v1 clients (verified field-by-field against both the matterjs-server TypeScript implementation and the Python client's protocol module; all extensions are additive/optional).
The endpoint is unauthenticated by design — securing it is the embedder's responsibility. This is documented prominently in the package README and the relevant class docs.
Fixes over the original implementation
Found during extraction reviews (all present in the deployed matterjs-server version):
ProxyBleChannel.close()cleared its connected flag beforebtpSession.close(), so the courtesyDisconnectwas never sent on normal channel close and the proxy client kept the GATT link. Now ordered likeNobleBleChannel, with the disconnect send bounded to 5 s so teardown cannot hang on an unresponsive proxy client.write_and_subscribe(atomic C1 write + C2 CCCD enable). The original JS example client never did, although the hub sends it for every BTP handshake — only the Python client worked.scan_stoppedlanding inside thestart_scanround-trip could permanently strand the client-side scan flag; discovery then silently returned nothing.onceExclusive("write")silently drops concurrent completion callbacks), malformedservice_dataentries no longer discard the whole peripheral, and compact service-UUID forms are canonicalized before matching.Testing
@matter/ws-ble(ESM/CJS/Web): protocol golden vectors pinning the v1 wire format, hub multi-client/ownership lifecycle, full hub↔client integration including the BTP handshake indication-before-response race, and a real-WebSocket smoke test through@matter/nodejs-ws.@matter/generalgains framing-layer tests (codec vectors, handshake both roles, command correlation/timeouts, close/cleanup paths).matterjs-server migration to this package (and whether its
ble-proxypackage remains as a compatibility wrapper) is tracked separately; the Python client stays in matterjs-server.🤖 Generated with Claude Code
Verification
Run at branch tip before opening the PR (repo root):
npm run build -- --clean— clean, zero errorsnpm run format-verify— "All matched files use Prettier code style!"npm run lint— zero warnings/errorsnpm test— full monorepo suite, exit 0;@matter/ws-ble56/56 and@matter/general1326/1326, each on ESM, CJS, and Web targetsprotocol.py: protocol version 1, opcodes 0x01/0x02/0x03,>BHframe header — identical